Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 779af8b185ecab14bbe0d9a496fc13523993526e


Parents : 313a661
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-05-02T23:09:14-05:00

feat(TutorialModal): add migration options and handling in tutorial modal

Changes

2 files changed, 485 insertions(+), 0 deletions(-)


Diff

diff --git a/meshchatx/src/frontend/components/TutorialModal.vue b/meshchatx/src/frontend/components/TutorialModal.vue
index 2b6690ca..3fd08f62 100644
--- a/meshchatx/src/frontend/components/TutorialModal.vue
+++ b/meshchatx/src/frontend/components/TutorialModal.vue
@@ -64,6 +64,38 @@
{{ $t("tutorial.welcome_desc") }}
</p>
</div>
+ <div
+ v-if="migrationOffer && migrationOffer.show_choice"
+ class="w-full max-w-xl mx-auto p-4 rounded-2xl border border-amber-200 dark:border-amber-900/50 bg-amber-50/90 dark:bg-amber-950/40 text-left space-y-3"
+ >
+ <div class="font-semibold text-amber-950 dark:text-amber-100">
+ {{ $t("tutorial.migration_title") }}
+ </div>
+ <p class="text-sm text-amber-950/90 dark:text-amber-100/90">
+ {{ $t("tutorial.migration_desc") }}
+ </p>
+ <div class="flex flex-col sm:flex-row gap-2 justify-stretch sm:justify-end">
+ <button
+ type="button"
+ class="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium disabled:opacity-50"
+ :disabled="migrationBusy"
+ @click="migrationMigrate"
+ >
+ {{ $t("tutorial.migration_migrate") }}
+ </button>
+ <button
+ type="button"
+ class="px-4 py-2 rounded-lg border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 text-sm font-medium disabled:opacity-50"
+ :disabled="migrationBusy"
+ @click="migrationFresh"
+ >
+ {{ $t("tutorial.migration_fresh") }}
+ </button>
+ </div>
+ <p v-if="migrationBusy" class="text-xs text-center text-gray-600 dark:text-zinc-400">
+ {{ $t("tutorial.migration_working") }}
+ </p>
+ </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 w-full mt-8">
<div
class="flex items-start gap-4 p-4 rounded-2xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800 transition-all hover:scale-[1.03] hover:shadow-xl hover:z-10"
@@ -967,6 +999,38 @@
{{ $t("tutorial.welcome_desc") }}
</p>
</div>
+ <div
+ v-if="migrationOffer && migrationOffer.show_choice"
+ class="w-full max-w-2xl mx-auto p-5 rounded-2xl border border-amber-200 dark:border-amber-900/50 bg-amber-50/90 dark:bg-amber-950/40 text-left space-y-3"
+ >
+ <div class="font-semibold text-amber-950 dark:text-amber-100">
+ {{ $t("tutorial.migration_title") }}
+ </div>
+ <p class="text-sm text-amber-950/90 dark:text-amber-100/90">
+ {{ $t("tutorial.migration_desc") }}
+ </p>
+ <div class="flex flex-col sm:flex-row gap-2 justify-stretch sm:justify-end">
+ <button
+ type="button"
+ class="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium disabled:opacity-50"
+ :disabled="migrationBusy"
+ @click="migrationMigrate"
+ >
+ {{ $t("tutorial.migration_migrate") }}
+ </button>
+ <button
+ type="button"
+ class="px-4 py-2 rounded-lg border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 text-sm font-medium disabled:opacity-50"
+ :disabled="migrationBusy"
+ @click="migrationFresh"
+ >
+ {{ $t("tutorial.migration_fresh") }}
+ </button>
+ </div>
+ <p v-if="migrationBusy" class="text-xs text-center text-gray-600 dark:text-zinc-400">
+ {{ $t("tutorial.migration_working") }}
+ </p>
+ </div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 w-full mt-12">
<div
class="flex items-start gap-6 p-6 rounded-3xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800 transition-all hover:scale-[1.03] hover:shadow-2xl hover:z-10"
@@ -1914,6 +1978,8 @@ export default {
bootstrapCommunitySectionOpen: true,
bootstrapAutoPickDone: false,
pickingRandomBootstraps: false,
+ migrationOffer: null,
+ migrationBusy: false,
};
},
computed: {
@@ -2012,6 +2078,7 @@ export default {
this.loadDiscoveryBootstrapDefaults();
this.loadCommunityInterfaces();
this.loadDiscoveredInterfaces();
+ this.refreshMigrationOffer();
this.discoveryInterval = setInterval(() => {
this.loadDiscoveredInterfaces();
}, 5000);
@@ -2051,6 +2118,7 @@ export default {
this.bootstrapDiscoveredSectionOpen = true;
this.bootstrapCommunitySectionOpen = true;
this.bootstrapAutoPickDone = false;
+ await this.refreshMigrationOffer();
await this.loadDiscoveryBootstrapDefaults();
await this.loadCommunityInterfaces();
await this.loadDiscoveredInterfaces();
@@ -2100,6 +2168,50 @@ export default {
this.loadingDiscovered = false;
}
},
+ async refreshMigrationOffer() {
+ this.migrationOffer = null;
+ try {
+ const response = await window.api.get("/api/v1/app/info");
+ const m = response.data?.app_info?.migration;
+ if (m && m.show_choice) {
+ this.migrationOffer = m;
+ }
+ } catch (e) {
+ console.error("Failed to load migration status:", e);
+ }
+ },
+ async migrationMigrate() {
+ if (this.migrationBusy || !this.migrationOffer) return;
+ this.migrationBusy = true;
+ try {
+ await window.api.post("/api/v1/setup/storage-migration", { action: "migrate" });
+ ToastUtils.success(this.$t("tutorial.migration_done_restart"));
+ if (window.electron && typeof window.electron.relaunch === "function") {
+ await window.electron.relaunch();
+ }
+ } catch (e) {
+ ToastUtils.error(e.response?.data?.error || this.$t("tutorial.migration_failed"));
+ console.error(e);
+ } finally {
+ this.migrationBusy = false;
+ }
+ },
+ async migrationFresh() {
+ if (this.migrationBusy || !this.migrationOffer) return;
+ this.migrationBusy = true;
+ try {
+ await window.api.post("/api/v1/setup/storage-migration", { action: "fresh" });
+ ToastUtils.success(this.$t("tutorial.migration_done_restart"));
+ if (window.electron && typeof window.electron.relaunch === "function") {
+ await window.electron.relaunch();
+ }
+ } catch (e) {
+ ToastUtils.error(e.response?.data?.error || this.$t("tutorial.migration_failed"));
+ console.error(e);
+ } finally {
+ this.migrationBusy = false;
+ }
+ },
async reloadReticulum() {
this.reloadingReticulum = true;
try {

diff --git a/tests/frontend/TutorialModalMigration.test.js b/tests/frontend/TutorialModalMigration.test.js
new file mode 100644
index 00000000..df6ee3ec
--- /dev/null
+++ b/tests/frontend/TutorialModalMigration.test.js
@@ -0,0 +1,373 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { mount, flushPromises } from "@vue/test-utils";
+import { createRouter, createWebHashHistory } from "vue-router";
+import { createI18n } from "vue-i18n";
+import { createVuetify } from "vuetify";
+import TutorialModal from "../../meshchatx/src/frontend/components/TutorialModal.vue";
+import en from "../../meshchatx/src/frontend/locales/en.json";
+import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
+
+vi.mock("../../meshchatx/src/frontend/js/GlobalState", () => ({
+ default: {
+ config: { theme: "light", language: "en" },
+ hasPendingInterfaceChanges: false,
+ modifiedInterfaceNames: new Set(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ },
+}));
+
+const axiosMock = { get: vi.fn(), post: vi.fn(), patch: vi.fn() };
+
+const vuetify = createVuetify();
+
+const i18n = createI18n({
+ legacy: false,
+ locale: "en",
+ messages: { en },
+});
+
+function discoveryApiHandlers(migrationPayload) {
+ return (url) => {
+ if (url === "/api/v1/app/info") {
+ return Promise.resolve({
+ data: {
+ app_info: {
+ migration: migrationPayload,
+ },
+ },
+ });
+ }
+ if (url === "/api/v1/reticulum/discovery") {
+ return Promise.resolve({ data: { discovery: {} } });
+ }
+ if (url === "/api/v1/community-interfaces") {
+ return Promise.resolve({ data: { interfaces: [] } });
+ }
+ if (url === "/api/v1/reticulum/discovered-interfaces") {
+ return Promise.resolve({ data: { interfaces: [], active: [] } });
+ }
+ return Promise.resolve({ data: {} });
+ };
+}
+
+const dialogStubs = {
+ LanguageSelector: true,
+ MaterialDesignIcon: true,
+ Toggle: true,
+ VIcon: { template: '<span class="v-icon-stub"/>' },
+};
+
+const pageStubs = {
+ VIcon: { template: '<span class="v-icon-stub"/>' },
+ LanguageSelector: true,
+ MaterialDesignIcon: true,
+ Toggle: true,
+};
+
+describe("TutorialModal getting started migration", () => {
+ beforeEach(() => {
+ window.api = axiosMock;
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ delete window.electron;
+ });
+
+ it("dialog show() loads migration offer when app_info reports show_choice", async () => {
+ axiosMock.get.mockImplementation(
+ discoveryApiHandlers({
+ show_choice: true,
+ legacy_path: "/home/x/.reticulum-meshchat",
+ target_path: "/home/x/.reticulum-meshchatx",
+ mode: "redirect_to_legacy",
+ }),
+ );
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.show();
+ await flushPromises();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.migrationOffer).toEqual(
+ expect.objectContaining({
+ show_choice: true,
+ legacy_path: "/home/x/.reticulum-meshchat",
+ target_path: "/home/x/.reticulum-meshchatx",
+ }),
+ );
+ await vi.waitFor(
+ () => {
+ const t = document.body.textContent || "";
+ return (
+ t.includes(en.tutorial.migration_title) &&
+ t.includes(en.tutorial.migration_migrate) &&
+ t.includes(en.tutorial.migration_fresh)
+ );
+ },
+ { timeout: 4000 },
+ );
+
+ wrapper.unmount();
+ });
+
+ it("dialog migrate posts storage-migration and toasts success", async () => {
+ axiosMock.get.mockImplementation(
+ discoveryApiHandlers({
+ show_choice: true,
+ legacy_path: "/a/.reticulum-meshchat",
+ target_path: "/a/.reticulum-meshchatx",
+ }),
+ );
+ axiosMock.post.mockImplementation((url, body) => {
+ if (url === "/api/v1/setup/storage-migration") {
+ expect(body).toEqual({ action: "migrate" });
+ return Promise.resolve({ data: { ok: true, restart_required: true } });
+ }
+ return Promise.resolve({ data: {} });
+ });
+ window.electron = { relaunch: vi.fn().mockResolvedValue(undefined) };
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.show();
+ await flushPromises();
+ await wrapper.vm.$nextTick();
+
+ await wrapper.vm.migrationMigrate();
+ await flushPromises();
+
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/setup/storage-migration", { action: "migrate" });
+ expect(ToastUtils.success).toHaveBeenCalledWith(en.tutorial.migration_done_restart);
+ expect(window.electron.relaunch).toHaveBeenCalled();
+
+ wrapper.unmount();
+ });
+
+ it("dialog fresh posts action fresh", async () => {
+ axiosMock.get.mockImplementation(
+ discoveryApiHandlers({
+ show_choice: true,
+ legacy_path: "/a/.reticulum-meshchat",
+ target_path: "/a/.reticulum-meshchatx",
+ }),
+ );
+ axiosMock.post.mockResolvedValue({ data: { ok: true, restart_required: true } });
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.show();
+ await flushPromises();
+ await wrapper.vm.$nextTick();
+
+ await wrapper.vm.migrationFresh();
+ await flushPromises();
+
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/setup/storage-migration", { action: "fresh" });
+ expect(ToastUtils.success).toHaveBeenCalledWith(en.tutorial.migration_done_restart);
+
+ wrapper.unmount();
+ });
+
+ it("dialog migration API error calls ToastUtils.error", async () => {
+ axiosMock.get.mockImplementation(
+ discoveryApiHandlers({
+ show_choice: true,
+ legacy_path: "/a/.reticulum-meshchat",
+ target_path: "/a/.reticulum-meshchatx",
+ }),
+ );
+ axiosMock.post.mockRejectedValue({
+ response: { data: { error: "target already has data" } },
+ });
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.show();
+ await flushPromises();
+ await wrapper.vm.$nextTick();
+
+ await wrapper.vm.migrationMigrate();
+ await flushPromises();
+
+ expect(ToastUtils.error).toHaveBeenCalledWith("target already has data");
+
+ wrapper.unmount();
+ });
+
+ it("migrationMigrate does nothing when migrationOffer is null", async () => {
+ axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.show();
+ await flushPromises();
+ expect(wrapper.vm.migrationOffer).toBeNull();
+
+ await wrapper.vm.migrationMigrate();
+ await flushPromises();
+
+ expect(axiosMock.post).not.toHaveBeenCalled();
+
+ wrapper.unmount();
+ });
+
+ it("dialog omits migration panel when show_choice is false", async () => {
+ axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.show();
+ await flushPromises();
+
+ expect(wrapper.vm.migrationOffer).toBeNull();
+
+ wrapper.unmount();
+ });
+
+ it("tutorial page mode loads migration offer on mount", async () => {
+ axiosMock.get.mockImplementation(
+ discoveryApiHandlers({
+ show_choice: true,
+ legacy_path: "/z/.reticulum-meshchat",
+ target_path: "/z/.reticulum-meshchatx",
+ }),
+ );
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [
+ {
+ path: "/tutorial",
+ name: "tutorial",
+ meta: { isPage: true },
+ component: { template: "<div/>" },
+ },
+ ],
+ });
+ await router.push("/tutorial");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ global: { plugins: [router, vuetify, i18n], stubs: pageStubs },
+ });
+
+ await flushPromises();
+ await flushPromises();
+
+ expect(wrapper.vm.migrationOffer).toEqual(
+ expect.objectContaining({
+ show_choice: true,
+ legacy_path: "/z/.reticulum-meshchat",
+ }),
+ );
+ expect(wrapper.text()).toContain(en.tutorial.migration_title);
+
+ wrapper.unmount();
+ });
+
+ it("refreshMigrationOffer leaves migration null when app_info has no migration", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/app/info") {
+ return Promise.resolve({ data: { app_info: { version: "1.0.0" } } });
+ }
+ if (url === "/api/v1/reticulum/discovery") {
+ return Promise.resolve({ data: { discovery: {} } });
+ }
+ if (url === "/api/v1/community-interfaces") {
+ return Promise.resolve({ data: { interfaces: [] } });
+ }
+ if (url === "/api/v1/reticulum/discovered-interfaces") {
+ return Promise.resolve({ data: { interfaces: [], active: [] } });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.refreshMigrationOffer();
+ await flushPromises();
+
+ expect(wrapper.vm.migrationOffer).toBeNull();
+
+ wrapper.unmount();
+ });
+});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────